/*******************************************************************************
 * This file is part of Crunch Network.
 *
 * Crunch Network is free software: you can redistribute it and/or modify it under the
 * terms of the GNU Lesser General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later version.
 *
 * Crunch Network is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
 * PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License along with
 * Crunch Network.  If not, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/

package com.crunch.network;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.HashMap;

/**
 * The {@code NetworkManager} is the main interface through which all network functionality is accessed. It is used to
 * glue together the various parts of the networking system, including low level features such as sockets and
 * virtual-connection management, and high level features such as messages and state data.
 */
public class NetworkManager {
	/**
	 * Bundles together data associated with a connection.
	 */
	public class ConnectionContext {
		// constructor is used by NetworkManager
		private ConnectionContext(NetworkConnection connection) {
			this.connection = connection;
			reader = new NetworkReader(messageRegistry, settings);
			writer = new NetworkWriter(messageRegistry, settings);
			packet = new NetworkPacket(settings);
			writer.setPacket(packet);
			ghostManager = new GhostManager(writer, settings);
			error = false;
		}

		/**
		 * Returns the connection associated with this context.
		 *
		 * @return  the connection associated with this context.
		 */
		public NetworkConnection getConnection() {
			return connection;
		}

		/**
		 * Returns the reader associated with this context.
		 *
		 * @return  the reader associated with this context.
		 */
		public NetworkReader getReader() {
			return reader;
		}

		/**
		 * Returns the writer associated with this context.
		 *
		 * @return  the writer associated with this context.
		 */
		public NetworkWriter getWriter() {
			return writer;
		}

		/**
		 * Returns the ghost manager associated with this context.
		 *
		 * @return  the ghost manager associated with this context.
		 */
		public GhostManager getGhostManager() {
			return ghostManager;
		}

		/**
		 * Returns whether this connection context has encountered an error.
		 *
		 * @return  whether this connection context has encountered an error.
		 */
		public boolean hasError() {
			return error || reader.hasError() || writer.hasError();
		}

		private NetworkConnection connection;
		private NetworkReader reader;
		private NetworkWriter writer;
		private NetworkPacket packet;
		private GhostManager ghostManager;
		private boolean error;
	}

	/**
	 * Initializes the network manager.
	 *
	 * @param   settings the network settings passed to components of the network system.
	 * @param   messageRegistry the message registry, which must be finalized, this network manager should use.
	 * @return  whether the connection manager was successfully initialized.
	 */
	public boolean initialize(NetworkSettings settings, MessageRegistry messageRegistry) {
		if (!messageRegistry.isFinalized()) {
			return false;
		}

		if (!settings.checkSettings()) {
			return false;
		}

		this.settings = new NetworkSettings(settings);
		// add an additional layer for the callbacks
		this.settings.callbacks = new Callbacks(settings.callbacks);

		connectionManager = new NetworkConnectionManager();
		this.messageRegistry = messageRegistry;
		connectionContexts = new HashMap<Integer, ConnectionContext>();

		try {
			connectionManager.initialize(settings);
		} catch (IOException e) {
			this.settings = null;
			connectionManager = null;
			this.messageRegistry = null;
			connectionContexts = null;
			return false;
		}

		return true;
	}

	/**
	 * Terminates the network manager.
	 */
	public void terminate() {
		connectionManager.terminate();
		connectionManager = null;
		messageRegistry = null;
		connectionContexts = null;
	}

	public boolean isInitialized() {
		if (settings == null) {
			return false;
		}

		assert connectionManager.isInitialized();
		return true;
	}

	/**
	 * Returns the network settings
	 *
	 * @return  the network settings.
	 */
	public NetworkSettings getNetworkSettings() {
		if (!isInitialized()) {
			return null;
		}

		// copy settings and restore original callbacks
		NetworkSettings s = new NetworkSettings(settings);
		s.callbacks = ((Callbacks) s.callbacks).getInternalCallbacks();
		return s;
	}

	/**
	 * Creates a new {@link com.crunch.network.NetworkConnection} for the given {@link NetworkAddress}. When explicitly
	 * creating connections with this method, {@link NetworkCallbacks#onReceiveNewConnection(NetworkConnection, long)}
	 * is not called. Note that the other end is not aware of this connection until it receives data or explicitly
	 * creates its own connection to this device.
	 *
	 * @param   address the address to connect to.
	 * @return  a {@link com.crunch.network.NetworkManager.ConnectionContext} instance representing the new connection.
	 * @throws  IllegalStateException if not yet initialized.
	 */
	public ConnectionContext createConnection(NetworkAddress address) {
		if (!isInitialized()) {
			throw new IllegalStateException("Network manager not initialized");
		}

		NetworkConnection connection = connectionManager.createConnection(address);
		ConnectionContext context = new ConnectionContext(connection);
		connectionContexts.put(connection.getConnectionId(), context);
		return context;
	}

	/**
	 * Destroys the connection with the given ID. The ID of a connection can be obtained by calling {@link
	 * NetworkConnection#getConnectionId()}. Note that this method does not alert the other end that the connection is
	 * being destroyed.
	 *
	 * @param   connectionId the ID of the connection to destroy.
	 * @return  whether the connection was destroyed.
	 * @throws  IllegalStateException if not yet initialized.
	 */
	public boolean destroyConnection(int connectionId) {
		if (!isInitialized()) {
			throw new IllegalStateException("Network manager not initialized");
		}

		if (connectionManager.destroyConnection(connectionId)) {
			ConnectionContext removedContext = connectionContexts.remove(connectionId);
			assert removedContext != null;
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Returns the connection context with the given ID. If no such connection context exists, {@code null} is returned.
	 *
	 * @param   connectionId the ID of the connection which should be returned.
	 * @return  the connection context associated with the given ID.
	 */
	public ConnectionContext getConnectionContext(int connectionId) {
		if (!isInitialized()) {
			throw new IllegalStateException("Network manager not initialized");
		}

		ConnectionContext connectionContext = connectionContexts.get(connectionId);
		assert connectionContext.getConnection() == connectionManager.getConnection(connectionId);
		return connectionContext;
	}

	/**
	 * Fills the remaining space in outgoing packets with ghost updates on all connections.
	 *
	 * @throws  IllegalStateException if not yet initialized.
	 */
	public void writeGhostUpdates() {
		if (!isInitialized()) {
			throw new IllegalStateException("Network manager not initialized");
		}

		for (ConnectionContext context : connectionContexts.values()) {
			if (!context.hasError() && context.writer.isPacketStarted()) {
				context.ghostManager.writeGhostUpdates();
			}
		}
	}

	/**
	 * Sends all ready packets on all connections.
	 *
	 * @throws  IllegalStateException if not yet initialized.
	 */
	public void sendAll() {
		if (!isInitialized()) {
			throw new IllegalStateException("Network manager not initialized");
		}

		long currentTimeMs = settings.callbacks.getCurrentTimeMs();
		for (ConnectionContext context : connectionContexts.values()) {
			if (!context.hasError() && context.writer.isReadyToSend()) {
				int packetId = connectionManager.send(context.connection.getConnectionId(),
						ByteBuffer.wrap(context.packet.getPacketData(), 0, context.packet.getPacketDataSize()));

				if (packetId < 0) {
					context.error = true;
				} else {
					context.writer.onPacketSent(packetId, currentTimeMs);
				}
				context.writer.clearPacket();
				context.writer.update(currentTimeMs);
			}
		}
	}

	/**
	 * Receives packets on all connections and updates network statistics.
	 *
	 * @throws  IllegalStateException if not yet initialized.
	 */
	public void receiveAll() {
		if (!isInitialized()) {
			throw new IllegalStateException("Network manager not initialized");
		}

		connectionManager.receiveAndProcess();

		long currentTimeMs = settings.callbacks.getCurrentTimeMs();
		for (ConnectionContext context : connectionContexts.values()) {
			context.reader.update(currentTimeMs);
		}
	}

	// internal callback layer - holds a references to the user-provided callbacks
	private class Callbacks implements NetworkCallbacks {
		private NetworkCallbacks internalCallbacks;

		// constructor taking internal callbacks
		public Callbacks(NetworkCallbacks internalCallbacks) {
			this.internalCallbacks = internalCallbacks;
		}

		// returns the internal callbacks
		public NetworkCallbacks getInternalCallbacks() {
			return internalCallbacks;
		}

		public long getCurrentTimeMs() {
			return internalCallbacks.getCurrentTimeMs();
		}

		public void log(LogLevel logLevel, String message) {
			internalCallbacks.log(logLevel, message);
		}

		public boolean onReceiveNewConnection(NetworkConnection connection, long currentTimeMs) {
			boolean keep = internalCallbacks.onReceiveNewConnection(connection, currentTimeMs);
			if (keep) {
				// Add a context for this connection
				ConnectionContext context = new ConnectionContext(connection);
				assert !connectionContexts.containsKey(connection.getConnectionId());
				connectionContexts.put(connection.getConnectionId(), context);
			}
			return keep;
		}

		public void onConnectionTimedOut(NetworkConnection connection, long currentTimeMs) {
			internalCallbacks.onConnectionTimedOut(connection, currentTimeMs);
			connectionContexts.remove(connection.getConnectionId());
		}

		public void processPacket(NetworkConnection connection, NetworkPacket packet, long currentTimeMs) {
			connectionContexts.get(connection.getConnectionId()).reader.onPacketReceived(packet, currentTimeMs);
			internalCallbacks.processPacket(connection, packet, currentTimeMs);
		}

		public void onPacketAcknowledged(NetworkConnection connection, int packetId, long currentTimeMs) {
			connectionContexts.get(connection.getConnectionId()).writer.onPacketAcknowledged(packetId);
			internalCallbacks.onPacketAcknowledged(connection, packetId, currentTimeMs);
		}

		public void onPacketDropped(NetworkConnection connection, int packetId, long currentTimeMs) {
			connectionContexts.get(connection.getConnectionId()).writer.onPacketDropped(packetId);
			internalCallbacks.onPacketDropped(connection, packetId, currentTimeMs);
		}
	}

	// network settings - note that an extra layer is added to the callbacks
	private NetworkSettings settings;

	// manages network connections
	private NetworkConnectionManager connectionManager;

	// manages message registration
	private MessageRegistry messageRegistry;

	// map of connection contexts
	private HashMap<Integer, ConnectionContext> connectionContexts;
}
